PLC 기계음 오탐지 원인 분석 및 해결

use-ai-audio-noise-guard.ts · 분석일: 2025-05-20 · 담당: charles-na

1. 문제 배경

현상

AI 아바타(핑퐁이)가 발화하는 도중, 네트워크가 일시적으로 불안정해지는 순간에 기계음 감지 로직이 오탐(False Positive)을 발생시켜 세션이 중단되는 현상이 보고됨.

재현 조건
AI 발화 중 패킷 손실(jitter spike, 네트워크 순단) 발생 → 기계음 감지 → 세션 강제 종료 (의도하지 않은 중단)
기계음 감지 조건 (기존)
CV < 0.35 AND ZCR ≥ 0.04

CV(변동계수)가 낮고 ZCR(영점교차율)이 적당히 높으면 기계음으로 판정. 정상 음성의 CV ≈ 0.85, 기계음의 CV ≈ 0.27.

PLC(Packet Loss Concealment)란

WebRTC 스택(Chrome)이 패킷 손실 구간을 감추기 위해 직전 오디오 프레임을 반복하는 기법. 수신자에게는 짧은 침묵 대신 균일한 파형이 들린다.

2. 오탐 발생 구조 분석

왜 PLC 프레임이 기계음처럼 보이는가
패킷 손실 발생
Chrome이 직전 RTP 프레임을 반복 출력
동일한 파형이 반복되므로 RMS 값이 연속 프레임에서 거의 동일해짐. → RMS 분산 ≈ 0 → CV = std/mean → CV ≈ 0
CV 조건 통과
CV < 0.35 만족
반복 프레임의 CV는 거의 0 ≈ 기계음(CV ≈ 0.27)보다도 훨씬 낮음. 기계음 판정의 주 기준을 확실히 통과.
ZCR 조건 — 함정
ZCR ≥ 0.04도 통과할 가능성이 높음
PLC는 직전 프레임을 그대로 복사하므로 원래 음성의 ZCR을 유지한다. AI 발화 중 ZCR이 0.04 이상이었다면 반복 구간에서도 ZCR ≥ 0.04가 유지됨. ZCR은 PLC를 걸러내지 못한다.
링 버퍼 누적 → 1차 감지
14프레임 중 10개 이상 기계음 프레임 축적
PLC 지속 구간(≈ 500–700ms) 동안 CV ≈ 0인 프레임이 연속 누적되어 링 버퍼 임계치(10/14)를 쉽게 초과.
재검증(200ms) → 확정
오탐 확정 → 세션 중단
2차 확인(4프레임 중 3프레임)도 PLC 구간이라면 쉽게 통과 → onNoiseDetected() 발화 → 세션 강제 종료.
정상 음성 vs 기계음 vs PLC 프레임 비교
신호 유형 CV (변동계수) ZCR (영점교차율) RMS 패턴 기계음 판정
정상 음성 ~0.85 (높음) ~0.08–0.15 불규칙 변동 통과 안 함
기계음 TTS ~0.27 (낮음) ≥ 0.04 주기적·안정 감지됨
PLC 반복 프레임 ≈ 0 (매우 낮음) 원래 음성과 동일 완전 균일(복사) 오탐! (기존)
무음 ∞ (mean≈0) ≈ 0 RMS < -50dB RMS 필터로 제외
핵심 포인트
ZCR은 PLC를 걸러내지 못한다. PLC 반복 프레임은 원본 음성의 ZCR을 그대로 복사하기 때문이다. 무음(ZCR ≈ 0)은 걸러낼 수 있지만, 소리가 있는 반복 프레임은 ZCR 조건을 통과한다.

3. 해결 방안 검토

방안 A — CV 판정 구간 완전 비활성화

네트워크 불안정 감지 시 기계음 판정 자체를 끄는 방법.

단점
실제 기계음 발생 시에도 네트워크가 불안정하다면 보호 기능이 꺼짐. 기계음 TTS와 PLC를 구분할 방법이 없어 오히려 취약점이 됨.
방안 B — PLC 의심 구간에서 기계음 확정 보류 ✓ 채택

WebRTC getStats()로 PLC 활성 여부를 직접 측정하여, PLC 활성 구간에서 나온 기계음 후보 프레임만 제외하는 방법.

장점
PLC가 없는 상태에서는 기계음 감지가 정상 동작. PLC 지표(concealedSamples 등)를 직접 측정하므로 정확. 신호 처리 계층을 수정하지 않고 게이팅만 추가.
PLC를 음성 신호로 감지하지 않고 네트워크 지표로 감지하는 이유

PLC 프레임 자체를 오디오 신호로 구분하는 것은 매우 어렵다. 직전 프레임 복사본이므로 합법적인 음성 반복과 구별되지 않는다. 반면 WebRTC inbound-rtp 통계는 브라우저가 내부적으로 PLC를 적용한 샘플 수(concealedSamples)를 직접 카운팅하므로 100% 신뢰도로 PLC 활성 여부를 알 수 있다.

4. 구현 내용

4-1. 새로운 상수 및 인터페이스

PLC 관련 상수
// PLC 감지용 WebRTC stats 샘플링 주기
const PLC_STATS_INTERVAL_MS             = 500;
// PLC active 상태가 stats 없이도 유지되는 시간
const PLC_STATE_TTL_MS                  = 1_500;
// 500ms 구간 내 concealed 비율이 이 값 이상이면 PLC 활성
const PLC_CONCEALED_SAMPLE_RATIO_THRESHOLD = 0.03;
// concealmentEvents 증가량이 이 값 이상이면 PLC 활성
const PLC_CONCEALMENT_EVENTS_THRESHOLD  = 2;
// packetsLost 증가량이 이 값 이상이면 PLC 활성
const PLC_PACKETS_LOST_THRESHOLD         = 3;
// jitter가 80ms 이상이면 PLC 위험 구간으로 간주
const PLC_JITTER_THRESHOLD_MS            = 80;
AudioInboundStats 인터페이스
export interface AudioInboundStats {
  timestamp: number;
  concealedSamples?:       number;
  concealmentEvents?:      number;
  totalSamplesReceived?:   number;
  silentConcealedSamples?: number;
  packetsLost?:            number;
  packetsReceived?:        number;
  jitter?:                 number;
}
PlcState 인터페이스
interface PlcState {
  active:                  boolean;
  updatedAt:               number;
  concealedSampleRatio:    number | null;
  concealmentEventsDelta:  number;
  packetsLostDelta:        number;
  jitterMs:                number | null;
}

4-2. samplePlcState — WebRTC stats 주기적 샘플링

동작 원리

500ms마다 getAudioInboundStats()를 호출해 inbound-rtp 통계를 읽는다. 이전 샘플 대비 델타값을 계산하여 PLC 활성 여부를 판정한다.

const concealedSamplesDelta    = stats.concealedSamples - prev.concealedSamples;
const totalSamplesReceivedDelta = stats.totalSamplesReceived - prev.totalSamplesReceived;
const concealedSampleRatio      = concealedSamplesDelta / totalSamplesReceivedDelta;

const active =
  concealedSampleRatio >= 0.03  // 수신 샘플의 3% 이상이 PLC
  || concealmentEventsDelta >= 2  // 500ms 안에 PLC 이벤트 2회 이상
  || packetsLostDelta >= 3        // 500ms 안에 패킷 3개 이상 손실
  || jitterMs >= 80;              // 지터 80ms 이상
TTL 설계 이유
PLC_STATE_TTL_MS = 1500ms — PLC가 감지된 후 stats가 정상화되더라도 1.5초간 active 상태를 유지한다. 링 버퍼(700ms) + 재검증(200ms) 전체 윈도우를 커버하기 위함.

4-3. isEffectiveNoiseFrame — 핵심 게이팅 로직

변경 전 → 변경 후
@@ analyzeFrame() — 링 버퍼 및 재검증 공통 @@
- ring.push(isNoiseFrame);
+ const isPlcActive =
+ plcState.active && Date.now() - plcState.updatedAt <= PLC_STATE_TTL_MS;
+ const isEffectiveNoiseFrame = isNoiseFrame && !isPlcActive;
+ ring.push(isEffectiveNoiseFrame); // 링 버퍼 (1차)
+ reverifyRing.push(isEffectiveNoiseFrame); // 재검증 (2차)

isNoiseFrame은 CV/ZCR 조건만 본다. isEffectiveNoiseFrame은 여기에 PLC 비활성 조건을 추가로 AND한다. PLC가 활성화된 동안은 기계음 후보 프레임이 카운트에 포함되지 않는다.

PLC 억제 로그 (디버깅용)
if (isNoiseFrame && isPlcActive) {
  logger.info("Noise candidate frame suppressed during PLC-active window", {
    cv:                   cv.toFixed(4),
    rmsDb:                rmsDb.toFixed(1),
    zcr:                  zcr.toFixed(4),
    concealedSampleRatio: ...,
    concealmentEventsDelta: plcState.concealmentEventsDelta,
    packetsLostDelta:       plcState.packetsLostDelta,
    jitterMs:               ...,
  });
}

기계음 후보이지만 PLC로 억제된 프레임은 INFO 레벨로 기록되어 실제 억제가 동작하는지 추적 가능.

5. catch 블록 버그 수정

문제 — catch 블록이 PLC 보호를 역방향으로 무력화
@@ samplePlcState() catch 블록 @@
- } catch (err) {
- logger.warn("Failed to sample inbound audio stats for PLC guard", { error: err });
- plcStateRef.current.active = false; // ← 버그
- }
+ } catch (err) {
+ logger.warn("Failed to sample inbound audio stats for PLC guard", { error: err });
+ // active 를 강제 해제하지 않음 — 마지막 상태를 TTL 만료까지 유지
+ }
왜 버그인가

pc.getStats()는 ICE 재연결(ICE restart) 중 예외를 던질 수 있다. ICE 재연결은 정확히 네트워크가 불안정한 순간에 발생한다. 즉, PLC 보호가 가장 필요한 시점에 getStats가 실패하고, catch가 active = false를 덮어써서 PLC 보호를 해제하는 역설이 발생한다.

수정 후 동작
getStats 예외 시 마지막으로 계산된 PLC 상태를 유지한다. PLC_STATE_TTL_MS(1500ms)가 만료되기 전까지는 active가 자연스럽게 유지되며, 이후 TTL 만료로 active = false가 된다.

6. 컴포넌트 연결 구조

데이터 흐름
WebRTC Layer
RTCPeerConnection
pc.getStats()
inbound-rtp 리포트
use-ai-session.ts
getAiAudioInboundStats
stats 파싱 후
AudioInboundStats 반환
use-guest-page-session.ts
prop 연결
test mode: undefined
prod: stats 콜백 전달
use-ai-audio-noise-guard.ts
samplePlcState
500ms 폴링
plcStateRef 갱신
analyzeFrame
isEffectiveNoiseFrame
PLC 활성 시
기계음 후보 억제
use-ai-session.ts — getAiAudioInboundStats 구현
const getAiAudioInboundStats = useCallback(async () => {
  const pc = pcRef.current;
  if (!pc || pc.connectionState === "closed") return null;

  const statsReport = await pc.getStats();
  let result: AudioInboundStats | null = null;

  statsReport.forEach((report) => {
    if (report.type === "inbound-rtp" && report.kind === "audio") {
      result = {
        timestamp:             report.timestamp,
        concealedSamples:      report.concealedSamples,
        concealmentEvents:     report.concealmentEvents,
        totalSamplesReceived:  report.totalSamplesReceived,
        silentConcealedSamples:report.silentConcealedSamples,
        packetsLost:           report.packetsLost,
        packetsReceived:       report.packetsReceived,
        jitter:                report.jitter,
      };
    }
  });

  return result;
}, []);
use-guest-page-session.ts — 테스트 모드와 프로덕션 분기
useAiAudioNoiseGuard({
  aiAudioStream: noiseTestHarness.syntheticNoiseStream ?? aiSession.aiAudioStream,
  isPeerTalking:  noiseTestHarness.isNoiseSimulating || aiSession.isPeerTalking,
  enabled:        noiseGuardEnabled,
  getAudioInboundStats: noiseTestHarness.syntheticNoiseStream
    ? undefined                            // 테스트 모드: PLC 보호 OFF (합성 신호라 실제 stats 없음)
    : aiSession.getAiAudioInboundStats,    // 프로덕션: 실제 WebRTC stats
  onNoiseDetected: () => { /* ... */ },
});
테스트 모드에서 PLC 보호를 OFF하는 이유
합성 노이즈 스트림(syntheticNoiseStream)은 1kHz 사인파를 직접 생성하므로 WebRTC stats가 존재하지 않는다. undefined를 전달하면 noise guard 내부에서 plcStateRef.active = false로 유지되어 기계음 감지가 의도대로 동작한다.

7. 타이밍 안전성 분석

PLC 감지 → 기계음 확정까지의 시간 여유
stats 폴링 지연
500ms
최악의 경우 PLC 시작 후 500ms 뒤에 감지
링 버퍼 채우는 시간
700ms
14프레임 × 50ms = 700ms
재검증 시간
200ms
4프레임 × 50ms = 200ms
PLC TTL
1500ms
감지 후 1.5초 유지
타이밍이 안전한 이유
PLC 발생 → 500ms 뒤 감지 → TTL 시작 → TTL 만료(1500ms 후).
링 버퍼(700ms) + 재검증(200ms) = 900ms < TTL 1500ms.
즉, PLC를 감지한 순간부터 1.5초 이내에 오는 모든 기계음 확정 시도는 TTL이 살아있어 차단된다.

8. 파라미터 레퍼런스

상수명 의미 비고
PLC_STATS_INTERVAL_MS 500 WebRTC stats 폴링 주기 너무 짧으면 성능 부담
PLC_STATE_TTL_MS 1500 PLC 활성 상태 유지 시간 링 버퍼(700) + 재검증(200) + 여유
PLC_CONCEALED_SAMPLE_RATIO_THRESHOLD 0.03 (3%) 500ms 내 concealed 샘플 비율 임계치 낮을수록 민감
PLC_CONCEALMENT_EVENTS_THRESHOLD 2 500ms 내 concealment 이벤트 횟수 1로 낮추면 더 민감
PLC_PACKETS_LOST_THRESHOLD 3 500ms 내 패킷 손실 수 2로 낮추면 더 보수적
PLC_JITTER_THRESHOLD_MS 80ms 지터 임계치 40ms로 낮추면 더 이른 감지
튜닝 고려 사항
PLC_JITTER_THRESHOLD_MS = 80ms는 보수적이다. 체감 품질 저하는 보통 40ms부터 시작되므로 40ms로 낮추면 더 이른 시점에 PLC를 감지할 수 있다. PLC_PACKETS_LOST_THRESHOLD = 3도 절대 카운트라 세션 길이에 영향 받을 수 있다. 2로 낮추는 것도 검토 가능.

9. 테스트 가이드

1단계 — 합성 기계음으로 기본 동작 확인 (PLC 없음)
  1. localhost:3000/app/guest/ 게스트 페이지 접속 후 수업 시작
  2. 개발자 도구 콘솔에서 window.__noiseTest?.start() 실행
  3. 10초 이내에 기계음 감지 알림(세션 중단) 발생하는지 확인
  4. 감지 시 로그: Mechanical noise confirmed after re-verification
2단계 — PLC 억제 로그 확인
  1. 실제 수업 중 네트워크 스로틀링 (Chrome DevTools → Network → Throttle)
  2. Noise candidate frame suppressed during PLC-active window 로그 확인
  3. 스로틀링 해제 후에도 1.5초간 억제 상태 유지되는지 확인
3단계 — 네트워크 불안정 재현 (주요 테스트)
  1. 수업 진행 중 AI 발화 시작 후 WiFi 일시 차단(1~2초) 또는 DevTools 스로틀
  2. 기계음 오탐 없이 세션이 유지되는지 확인
  3. 네트워크 복구 후 AI 음성이 정상 재개되는지 확인
  4. 콘솔 로그에서 PLC active 로그 순서 확인:
  INFO  Noise candidate frame suppressed during PLC-active window
       { cv: "0.0012", concealmentEventsDelta: 4, ... }
  ... (패킷 복구 후 1.5초 TTL 만료) ...
  INFO  Noise re-verification failed, resuming normal analysis
  (← 재검증 중 PLC 억제로 기계음 카운트 부족 → 오탐 회피)

10. 수정된 파일

파일 변경 내용
apps/web/hooks/use-ai-audio-noise-guard.ts PLC 상수 및 인터페이스 추가 · samplePlcState() 콜백 구현 · isEffectiveNoiseFrame 게이팅 적용 (링 버퍼 + 재검증) · stats 폴링 useEffect 추가 · catch 블록 버그 수정
apps/web/entities/guest-session/model/use-ai-session.ts getAiAudioInboundStats 콜백 추가 — RTCPeerConnection.getStats()에서 inbound-rtp audio 리포트 추출
apps/web/entities/guest-page-session/model/use-guest-page-session.ts useAiAudioNoiseGuardgetAudioInboundStats prop 연결 · 테스트 모드에서는 undefined 전달 (PLC 보호 OFF)

Generated: 2025-05-20 · PPI dev analysis